<?php
/*
# $Id: ModelManager.php 12 2006-11-21 12:51:48Z James $
*/

/*
# @class ModelManager
*/
class ModelManager extends Object {

	/*
	# @property string $modelName
	# Stores the name of the Model operated upon by this ModelManager.
	*/
	public $modelName;

	/*
	# @method ModelManager __construct( string $modelName )
	# $modelName	= Name of the Model for which this is the ModelManager
	#
	# Creates an instance.
	# Do not call this directly from your scripts, instead use "ModelManager::create(...)".
	*/
	public function __construct($modelName) {

		// Store Model name
		$this->modelName = $modelName;
	}

	/*
	# @method ModelManager get( string $modelName )
	# $modelName	= The name of the Model whose manager we want to retrieve
	#
	# Factory method that returns a Singleton instance of the ModelManager class that is used for
	# managing CRUD actions on Models of the type specified.
	*/
	final static public function create($modelName) {

		// Vars
		static $modelManagerInstances = array();

		// Return instance if already created
		if(isset($modelManagerInstances[$modelName])) {
			return $modelManagerInstances[$modelName];
		}

		// Create new instance and return
		$managerClassName = Inflector::modelName_modelManagerClass($modelName);
		try {
			$modelManagerInstances[$modelName] = new $managerClassName($modelName);
		}
		catch(BuanException $e) {
			SystemLog::add($e->getMessage(), SystemLog::CORE);
			$modelManagerInstances[$modelName] = new ModelManager($modelName);
		}
		return $modelManagerInstances[$modelName];
	}

	/*
	# @method array select( string $modelName, [ModelCriteria $criteria] )
	# $modelName	= Name of the Model type to be selected
	# $criteria		= A ModelCriteria object
	#
	# Returns an array of Models of the specified type, according to any given criteria.
	*/
	static public function select($modelName, $criteria=NULL) {

		// Create an instance of the Model type and it's Manager class
		$model = Model::create($modelName);
		$manager = ModelManager::create($modelName);

		// Process
		$records = array();
		try {
			// Get the DB connection used by Models of this type
			$DB = Database::getConnectionByModel($model);

			// Build, prepare and execute query
			//$sql = "SELECT {$model->getDbTableName()}.* FROM {$model->getDbTableName()}";
			if($criteria===NULL) {
				$c = new ModelCriteria();
			}
			else {
				$c = clone $criteria;
			}
			$c->selectField("`{$model->getDbTableName()}`.*");
			$c->selectTable($model->getDbTableName());
			$sql = $c->sql();
			$stmt = $DB->prepare($sql->query);
			foreach($sql->bindings as $binding) {
				$stmt->bindValue($binding->parameter, $binding->value, $binding->dataType);
			}
			$stmt->execute();

			// Create a list of objects to return
			// In order to speed things up, we don't issue individual "::load()" calls, but instead
			// manually create and populate each instance in this loop.
			// However, if the Model's Manager has a custom "load()" method, we need to use that instead.
			while($rec = $stmt->fetch(PDO::FETCH_ASSOC)) {

				// Populate our generic Model with the result
				// TODO: Only populate primary key here, then if the Model isn't
				// present in the ModelTracker, fully populate it.
				$model->populateFromArray($rec);

				// First, check if a Model exists in ModelTracker with a matching primary-key
				if($M = ModelTracker::getModelByPrimaryKey($modelName, $model->getPrimaryKeyValueToken())) {
					// Don't need to do anything
				}

				// Otherwise, create and populate an instance of the Model and add it to the ModelTracker
				else {
					// TODO: Determine if the $manager class has a custom "load()" method, and if so, we need to use it here

					// Create and populate
					$M = Model::create($modelName);
					$M->populateFromArray($rec);
					$M->isInDatabase(TRUE);		// We know the Model is in the database
					$M->hasChanged(FALSE);		// Calling "populateFromArray()" will set "hasChanged" to TRUE, so we reset it here.

					// Add instance to the ModelTracker
					ModelTracker::addModel($M);
				}

				// Add to object list
				$records[] = $M;
				unset($M);
			}

			// Result
			return $records;
		}
		catch(Exception $e) {

			// Log and return
			SystemLog::add($e->getMessage(), SystemLog::WARNING);
			return $records;
		}
	}

	/*
	# @method int selectCount( string $modelName, [ModelCriteria $criteria] )
	# $modelName	= Name of the Model type to be selected
	# $criteria	= A ModelCriteria object
	#
	# Returns a count of all records of the specified type that match the given
	# criteria (if specified.)
	#
	# TODO:
	# When PDOStatement->rowCount() is supported by all database drivers, use it
	# instead of the fetchAll() solution.
	*/
	static public function selectCount($modelName, $criteria=NULL) {

		// Create an instance of the Model type and it's Manager class
		$model = Model::create($modelName);

		// Get the DB connection used by Models of this type
		try {
			$DB = Database::getConnectionByModel($model);
		}
		catch(Exception $e) {
			SystemLog::add($e->getMessage(), SystemLog::WARNING);
			return 0;
		}

		// Build query criteria
		if($criteria===NULL) {
			$c = new ModelCriteria();
		}
		else {
			$c = clone $criteria;
			//$c->ungroupBy();
			//if($c->hasSelectFields()) {
			//	$c->groupBy("NULL");
			//}
		}
		$c->selectTable($model->getDbTableName());

		// If no fields have been specified in the SELECT portion of the query,
		// then we'll use COUNT(*)
		//
		// The method of counting the rows is one of:
		//	FETCH_ALL	= The query is run as-is and rows are counted from the
		//				result of $stmt->fetchAll()
		//	COUNT_SQL	= The COUNT(*) method is used (for simple queries that
		//				do not already contain and FIELDS in the SELECT portion)
		$countMethod = 'FETCH_ALL';
		if(!$c->hasSelectFields()) {
			$c->selectField("COUNT(*) AS numRecords");
			$countMethod = 'COUNT_SQL';
		}

		// Prepare and execute query
		$sql = $c->sql();
		if(!$stmt = $DB->prepare($sql->query)) {

			// Log and return
			SystemLog::add('Failed to prepare PDO statement: '.$sql, SystemLog::WARNING);
			return 0;
		}
		else {
			foreach($sql->bindings as $binding) {
				$stmt->bindValue($binding->parameter, $binding->value, $binding->dataType);
			}
			if(!$stmt->execute()) {
	
				// Log and return
				SystemLog::add('Query failed: '.$stmt->queryString, SystemLog::WARNING);
				return 0;
			}
		}

		// Get the count
		$rec = $stmt->fetchAll(PDO::FETCH_ASSOC);
		if(empty($rec) && $countMethod=='COUNT_SQL') {
			SystemLog::add('ModelManager::selectCount() has not managed to retrieve any count.', SystemLog::WARNING);
			return 0;
		}
		return $countMethod=='FETCH_ALL' ? count($rec) : (int)$rec[0]['numRecords'];
	}

	/*
	# @method PDOStatement sqlQuery( string|ModelCriteria $sql, [array $params, [string $connectionName]] )
	# $sql				= The SQL query to be executed
	# $params			= Parameters to bind to the $sql query (if using named
	#						parameters, array keys should be the param names)
	# $connectionName	= The db connection through which the SQL will be sent
	#
	# This method allows you to execute any arbitrary SQL statement and the
	# results are returned as a PDOStatement or FALSE if the query failed.
	#
	# If you want to use numeric parameters (ie. SELECT * FROM x WHERE y=?)
	# then pass $params as a normal 0-indexed array.
	# However, if you want to use named parameters
	# (ie. SELECT * FROM x WHERE y=:myparam), then send $params as a hash
	# key=>value pairs of ":param"=>"value".
	#
	# Really, you could just as easily use the PDO functions directly in your
	# code. This will give you more flexibilty with setting attributes, etc.
	# Just try to keep all database code within your Model or ModelManager
	# classes.
	*/
	static public function sqlQuery($sql, $params=array(), $connection=NULL) {

		// Get the database connection
		if(is_null($connection)) {
			try {
				$connection = Database::getConnection('default');
			}
			catch(Exception $e) {
				SystemLog::add($e->getMessage(), SystemLog::WARNING);
				return FALSE;
			}
		}

		// Execute the query
		try {
			if($sql instanceof ModelCriteria) {
				$sql = $sql->sql();
				$stmt = $connection->prepare($sql->query);
				foreach($sql->bindings as $binding) {
					$stmt->bindValue($binding->parameter, $binding->value, $binding->dataType);
				}
				$stmt->execute();
			}
			else {
				if(count($params)>0) {
					$stmt = $connection->prepare($sql);
					$stmt->execute($params);
				}
				else {
					$stmt = $connection->query($sql);
				}
			}
			return $stmt;
		}
		catch(PDOException $e) {
			$dbg = debug_backtrace();
			$msg = $e->getMessage()." (source: {$dbg[0]['file']} line {$dbg[0]['line']})";
			SystemLog::add('Query failed: '.$msg, SystemLog::WARNING);
			throw new PDOException($msg);
			return FALSE;
		}
	}

	/*
	# @method bool load( Model &$model )
	# $model	= This instance will be populated with the field data loaded form the database
	#
	# Load all field data into $model, using $model's primary-key on which to retrieve data from the database.
	# $model must be passed-by-reference because this method may replace it with an entirely
	# different instance from the ModelTracker.
	*/
	public function load(&$model) {

		// If no fields have been set/altered, then we've got no attributes to use for loading anything
		if(!$model->hasChanged()) {
			return TRUE;
		}

		// First, look in the ModelTracker to see if we've already previously loaded a Model matching it's primary-key
		if($tModel = ModelTracker::getModelByPrimaryKey($model->modelName, $model->getPrimaryKeyValueToken())) {
			$model = $tModel;
			return TRUE;
		}

		// Find and load Model from the database
		try {
			// Prepare and execute the query for loading this Model from the database
			$DB = Database::getConnectionByModel($model);
			$primaryKeys = explode(",", $model->getPrimaryKey());
			$primaryKeyValues = $model->getPrimaryKeyValue();
			$dbTableName = $model->getDbTableName();
			$sql = 'SELECT * FROM `'.$dbTableName.'` WHERE '.implode("=? AND ", $primaryKeys).'=?';
			$stmt = $DB->prepare($sql);
			$stmt->execute(is_array($primaryKeyValues) ? array_values($primaryKeyValues) : array($primaryKeyValues));

			// Populate the Model
			$records = $stmt->fetchAll(PDO::FETCH_ASSOC);
			if(count($records)==1) {

				// Populate
				$model->populateFromArray($records[0]);
				$model->isInDatabase(TRUE);
				$model->hasChanged(FALSE);

				// Add to ModelTracker
				ModelTracker::addModel($model);

				// Result
				return TRUE;
			}
			else if(count($records)==0) {
				$fnDump = implode(", ", array_keys($model->getDbData()));
				$fvDump = "'".implode("', '", array_values($model->getDbData()))."'";
				SystemLog::add("Cannot load Model from database (type:{$model->modelName}, table:{$model->getDbTableName()}, data:[{$fnDump}] => [{$fvDump}]", SystemLog::CORE);
				return FALSE;
			}
			else {
				$fnDump = implode(", ", array_keys($model->getDbData()));
				$fvDump = "'".implode("', '", array_values($model->getDbData()))."'";
				SystemLog::add("Found multiple Models on primary key (type:{$model->modelName}, table:{$model->getDbTableName()}, data:[{$fnDump}] => [{$fvDump}]", SystemLog::WARNING);
				return FALSE;
			}
		}
		catch(Exception $e) {
			SystemLog::add($e->getMessage(), SystemLog::WARNING);
			return FALSE;
		}
	}

	/*
	# @method bool save( Model &$model )
	# $model	= Model to be saved
	#
	# Saves the given Model to the database.
	# If the Model already exists in the database, an UPDATE will issued instead.
	*/
	public function save(&$model) {

		// Get a DB connection for the given Model and start a transaction.
		// MySQL: Tables must use "innodb" engine for transactions to work.
		try {
			$DB = Database::getConnectionByModel($model);
			$DB->beginTransaction();
		}
		catch(Exception $e) {
			SystemLog::add($e->getMessage(), SystemLog::NOTICE);
		}

		// First, save all Models in M:1 relationship so we can get their primary key IDs for use in setting foreign-keys in $model
		foreach($model->getRelatedModelsByCardinality(ModelRelation::MANY_TO_ONE) as $foreignKey=>$rModel) {

			// If the foreign-key is set to 0 (zero), then don't save $rModel
			if($model->{$foreignKey}==0) {
				continue;
			}

			// Ignore Models that have not changed (ie. do not need saving)
			// This will prevent recursion
			if(!$rModel->hasChanged()) {
				if(!isset($model->{$foreignKey}) || $model->{$foreignKey}<=-1) {
					$model->{$foreignKey} = $rModel->getPrimaryKeyValue();
				}
				continue;
			}

			// Save and set foreign-key
			// Foreign-keys are only set if they are not currently defined, or their current value is -1
			else if($rModel->getModelManager()->save($rModel)) {
				if(!isset($model->{$foreignKey}) || $model->{$foreignKey}<=-1) {
					$model->{$foreignKey} = $rModel->getPrimaryKeyValue();
				}
			}

			// On save failure, rollback entire transaction and return
			else {
				try {
					$DB->rollBack();
				}
				catch(PDOException $e) {
					SystemLog::add($e->getMessage(), SystemLog::WARNING);
				}
				return FALSE;
			}
		}

		// Save $model and related Models
		try {
			// If $model has previously been saved (ie. it's already in the database), then issue an "update" instead
			$inUpdateMode = FALSE;
			if($model->isInDatabase()) {
				$inUpdateMode = TRUE;
			}

			// Only save $model if it has actually changed
			if($model->hasChanged()) {

				// Construct the query for saving/updating this Model's field data to the database
				$dbData = $model->getDbData();

				// TODO: Should only unset if field is auto_increment, so commented out for now.
				// Needs to be unset to work in MySQL 4 though. Sort something out.
				/*if(isset($dbData[$model->getPrimaryKey()])) {
					unset($dbData[$model->getPrimaryKey()]);
				}*/

				$dbTableName = $model->getDbTableName();
				if(!$inUpdateMode) {
					$sql = 'INSERT INTO `'.$dbTableName.'` ('.implode(",", array_keys($dbData)).')
						VALUES ('.implode(",", array_fill(0, count($dbData), '?')).')';
				}
				else {
					if(isset($dbData[$model->getPrimaryKey()])) {
						unset($dbData[$model->getPrimaryKey()]);
					}
					$sql = 'UPDATE `'.$dbTableName.'` SET ';
					$sep = '';
					foreach($dbData as $k=>$v) {
						$sql .= $sep."$k=?";
						$sep = ', ';
					}
					$sql .= ' WHERE '.$model->getPrimaryKey().'=?';
					$dbData[$model->getPrimaryKey()] = $model->getPrimaryKeyValue();
				}

				// Prepare and execute
				$stmt = $DB->prepare($sql);
				if(!$stmt->execute(array_values($dbData))) {
					SystemLog::add("PDOStatement execution failed.", SystemLog::WARNING);
					return FALSE;
				}

				// If saving for the first time, and the primary key is an
				// "auto_increment" field then store the newly generated ID in
				// this Model's primary key.
				if(!$inUpdateMode && ($lastInsertId = $DB->lastInsertId())>0) {
					$model->setPrimaryKeyValue($lastInsertId);
				}
			}

			// Set flags
			$model->isInDatabase(TRUE);
			$model->hasChanged(FALSE);

			// Add $model to the ModelTracker
			ModelTracker::addModel($model);

			// Save all Models in 1:M relationships
			foreach($model->getRelatedModelsByCardinality(ModelRelation::ONE_TO_MANY) as $rModelName=>$relatedModels) {
				foreach($relatedModels as $relRef=>$rModels) {
					foreach($rModels as $rModel) {

						// Set foreign-key and save
						// Foreign-keys are only set if they are not currently defined, or their current value is -1
						$foreignKey = $rModel->getForeignKey($model, $relRef);
						if(!isset($rModel->{$foreignKey}) || $rModel->{$foreignKey}<=-1) {
							//$foreignKey = $rModel->getForeignKey($model->modelName);
							$rModel->{$foreignKey} = $model->getPrimaryKeyValue();
						}
						if(!$rModel->getModelManager()->save($rModel)) {
							//try {
								$DB->rollBack();
							//}
							//catch(PDOException $e) {
							//	SystemLog::add($e->getMessage(), SystemLog::WARNING);
							//}
							return FALSE;
						}
					}
				}
			}

			// Commit and return
			$DB->commit();
			return TRUE;
		}
		catch(PDOException $e) {

			// Reset flags
			$model->isInDatabase($inUpdateMode ? TRUE : FALSE);
			$model->hasChanged(TRUE);

			// Log, rollback and return
			SystemLog::add($e->getMessage(), SystemLog::WARNING);
			try {
				$DB->rollBack();
			}
			catch(PDOException $e) {
				SystemLog::add($e->getMessage(), SystemLog::WARNING);
			}
			return FALSE;
		}
	}

	/*
	# @method bool update( Model &$model )
	# $model	= Model to be saved/updated
	#
	# Updates an existing Model.
	*/
	public function update(&$model) {

		// If nothing has changed in the $model since it was first loaded, then don't issue any updates
		if(!$model->hasChanged()) {
			return TRUE;
		}

		// Pass through to $this->save()
		return $this->save($model);
	}

	/*
	# @method bool delete( Model &$model )
	# $model	= Model to be deleted form the database
	#
	# Removes the specified Model form the database.
	*/
	public function delete(&$model) {

		// Check if Model is actually in the database
		// (can't delete Models that are not in persistent storage)
		if(!$model->isInDatabase()) {
			SystemLog::add("Attempting to delete non-persistent Model ({$model->modelName} #{$model->getPrimaryKeyValue()}).", SystemLog::CORE);
			return TRUE;
		}

		// Get a DB connection for the given Model and start a transaction.
		try {
			$DB = Database::getConnectionByModel($model);
			$dbTransactionActive = $DB->getAttribute(PDO::ATTR_AUTOCOMMIT)==1 ? TRUE : FALSE;
			$DB->beginTransaction();
			$dbTransactionActive = TRUE;
		}
		catch(Exception $e) {
			SystemLog::add($e->getMessage(), SystemLog::NOTICE);
			$dbTransactionActive = FALSE;
		}

		// Delete
		$original_isInDatabase = $model->isInDatabase();
		$original_hasChanged = $model->hasChanged();
		try {
			// Prepare and execute
			$sql = 'DELETE FROM `'.$model->getDbTableName().'` WHERE '.$model->getPrimaryKey().'=?';
			$stmt = $DB->prepare($sql);
			$stmt->execute(array($model->getPrimaryKeyValue()));

			// Load all related Models, ready to delete them if required
			// This needs to be done BEFORE we set the isInDatabase flag to FALSE because
			// Model::loadRelatedModels() will only execute if the Model is in the database.
			$model->loadRelatedModels();

			// Set attributes
			$model->isInDatabase(FALSE);
			$model->hasChanged(FALSE);

			// Find all relationships that include $model
			$relations = ModelRelation::getRelation($model->modelName);
			foreach($relations as $relation) {
				switch($relation->getCardinality()) {

					// 1:M (including 1:1, ie. 1:M,1)
					case ModelRelation::ONE_TO_MANY:

						// Delete all "M" Models, if cascading is allowed
						$relatedModels = $model->getRelatedModels($relation->getTargetModel(), $relation->getReference());
						if($relation->getLimit()==1) {
							$relatedModels = $relatedModels===NULL ? array() : array($relatedModels);
						}
						if(!$relation->getOption('nocascade')) {
							foreach($relatedModels as $rModel) {
								$model->removeRelatedModel($rModel);
								if(!$rModel->getModelManager()->delete($rModel)) {
									// TODO: Do we rollback here?
								}
							}
						}

						// Set the foreign key on all "M" Models to 0 (zero), if
						// cascading is prohibited.
						// Once the foreign-key is set, the related Model is then saved.
						// TODO: Setting the field to "0" doesn't help if the
						// foreign key is not an integer! Need to support strings or
						// just use NULL?
						else {
							foreach($relatedModels as $rModel) {
								$model->removeRelatedModel($rModel);
								$rModel->{$rModel->getForeignKey($model, $relation->getReference())} = 0;
								if(!$rModel->getModelManager()->save($rModel)) {
									// TODO: Rollback?
								}
							}
						}

						// Break
						break;

					// M:1
					case ModelRelation::MANY_TO_ONE:

						// Break relation between Models
						$relatedModel = $model->getRelatedModels($relation->getTargetModel(), $relation->getReference());
						if($relatedModel!==NULL) {
							$model->removeRelatedModel($relatedModel);
						}
						break;

					// M:M
					case ModelRelation::MANY_TO_MANY:

						// M:M relationships should actually already be broken down into 1:M/M:1
						// so this case can be ignored.
						break;

					// default
					default:
						break;
				}
			}

			// Return
			if($dbTransactionActive) {
				$DB->commit();
			}

			// Result
			return TRUE;
		}
		catch(PDOException $e) {

			// Set attributes
			$model->isInDatabase($original_isInDatabase);
			$model->hasChanged($original_hasChanged);

			// Log, rollback and return
			SystemLog::add($e->getMessage(), SystemLog::WARNING);
			try {
				if($dbTransactionActive) {
					$DB->rollBack();
				}
			}
			catch(PDOException $e) {
				SystemLog::add($e->getMessage(), SystemLog::WARNING);
			}
			return FALSE;
		}
	}

	/*
	# @method bool refresh( Model &$model )
	# $model	= Model
	#
	# Refresh the Model by reloading it's field data from the database.
	*/
	public function refresh(&$model) {

		// Reload
		return $this->load($model);
	}
}
?>